import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg'; import { useFavorites, useJobDetail } from '../../src/hooks'; import { buildJobShareText } from '../../src/api'; import { REVIEW_DIMENSIONS, REVIEW_RATING_MAX, REVIEW_TAG_SUGGESTIONS, } from '../../src/constants'; import { shareText } from '../../src/lib/shareFile'; import {colors, spacing} from '../../src/theme'; import { formatThousands } from '../../src/utils'; import type { EmployeeReview, JobDimensionScore, JobReviewCreateInput, JobReviewSummary, } from '../../src/types'; /** 星级色:星级图标在候选人详情、岗位评价页均复用 compareOrange,此处沿用同一语义色 */ const STAR_COLOR = colors.compareOrange; /* ===================== 雷达图(6 维度,SVG 绘制) ===================== */ /** 六边形雷达图:同心网格 + 轴线 + 数据多边形 + 维度标签 */ function RadarChart({ dimensions, size = 160, }: { dimensions: JobDimensionScore[]; size?: number; }) { const cx = size / 2; const cy = size / 2; const count = dimensions.length; // 数据/网格最大半径(预留标签空间) const R = size / 2 - 36; const labelR = R + 16; const levels = [0.25, 0.5, 0.75, 1]; const angleFor = (i: number) => (-90 + i * (360 / count)) * (Math.PI / 180); const pointFor = (i: number, ratio: number) => ({ x: cx + R * ratio * Math.cos(angleFor(i)), y: cy + R * ratio * Math.sin(angleFor(i)), }); const polygonPoints = (ratio: number) => dimensions .map((_, i) => { const p = pointFor(i, ratio); return `${p.x},${p.y}`; }) .join(' '); const dataPoints = dimensions.map((d, i) => pointFor(i, d.value / 100)); return ( {/* 同心网格 */} {levels.map((ratio) => ( ))} {/* 轴线 */} {dimensions.map((_, i) => { const p = pointFor(i, 1); return ( ); })} {/* 数据多边形 */} `${p.x},${p.y}`).join(' ')} fill={colors.primary} fillOpacity={0.15} stroke={colors.primary} strokeWidth={2} /> {/* 数据点 */} {dataPoints.map((p, i) => ( ))} {/* 维度标签 */} {dimensions.map((d, i) => { const lx = cx + labelR * Math.cos(angleFor(i)); const ly = cy + labelR * Math.sin(angleFor(i)); return ( {d.name} ); })} ); } /* ===================== 子组件 ===================== */ /** 顶部导航:返回 + 标题 + 收藏 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */ function Header({ onBack, onShare, favorited, onToggleFavorite, }: { onBack: () => void; onShare?: () => void; favorited?: boolean; onToggleFavorite?: () => void; }) { return ( 岗位详情 {onToggleFavorite ? ( ) : null} {onShare ? ( ) : null} ); } /** 分区标题:竖条装饰 + 标题 + 可选右侧链接 */ function SectionTitle({ title, link, onLink, }: { title: string; link?: string; onLink?: () => void; }) { return ( {title} {link ? ( {link} > ) : null} ); } /* ===================== 评价:星级 / 汇总 / 卡片 / 编辑器 ===================== */ /** * 星级组件。 * 传 `onChange` 时可点击打分(编辑器内用),不传则只读(列表展示用)。 * 只读态用实心/描边星区分,避免仅靠颜色传达分数(WCAG 1.4.1)。 */ function Stars({ value, size = 14, onChange, }: { value: number; size?: number; onChange?: (next: number) => void; }) { const readonly = !onChange; return ( {Array.from({ length: REVIEW_RATING_MAX }, (_, i) => { const starValue = i + 1; const filled = starValue <= value; const star = ( ); if (readonly) { return ( {star} ); } return ( onChange(starValue)} accessibilityRole="button" accessibilityLabel={`打 ${starValue} 分`} > {star} ); })} ); } /** * 评价汇总卡:平均分 + 星级分布 + 热门标签。 * 无评价时不渲染(空态由下方「暂无员工评价」承担,避免两个空块叠着)。 */ function ReviewSummaryCard({ summary }: { summary: JobReviewSummary }) { if (summary.total === 0) return null; const maxCount = Math.max(1, ...summary.distribution.map((d) => d.count)); return ( {summary.averageRating.toFixed(1)} {summary.total} 条真实评价 {summary.distribution.map((d) => ( {d.rating}星 {d.count} ))} {summary.topTags.length > 0 ? ( {summary.topTags.map((tag) => ( {tag} ))} ) : null} ); } /** * 单条评价卡。 * `canEdit` 为真(本人评价)时展示「编辑 / 删除」;八维评分按行铺开, * 未打分的维度不渲染,避免一屏全是 0 分占位。 */ function ReviewCard({ review, onEdit, onDelete, }: { review: EmployeeReview; onEdit: () => void; onDelete: () => void; }) { const scoredDims = REVIEW_DIMENSIONS.filter( (dim) => (review.dimensionScores?.[dim.key] ?? 0) > 0, ); return ( {review.userName} {review.userRole} {review.time} {typeof review.rating === 'number' && review.rating > 0 ? ( {review.rating}.0 ) : null} {review.content} {scoredDims.length > 0 ? ( {scoredDims.map((dim) => ( {dim.label} {review.dimensionScores?.[dim.key]} ))} ) : null} {review.tags.length > 0 ? ( {review.tags.map((tag) => ( {tag} ))} ) : null} {review.canEdit ? ( 编辑 删除 ) : null} ); } /** 编辑器初值:新建时全 0,编辑时用已有评价回填 */ interface ReviewDraft { rating: number; content: string; tags: string[]; dimensionScores: Record; } const EMPTY_DRAFT: ReviewDraft = { rating: 0, content: '', tags: [], dimensionScores: {} }; function draftFromReview(review: EmployeeReview | null): ReviewDraft { if (!review) return EMPTY_DRAFT; return { rating: review.rating ?? 0, content: review.content, tags: [...review.tags], dimensionScores: { ...(review.dimensionScores ?? {}) }, }; } /** * 评价编辑器(新建 / 编辑共用)。 * 后端要求 rating + 八维 + 标签 + 正文一起提交,故不能再沿用底部的单行输入框。 * 校验在提交前做:综合评分必填、正文必填。 */ function ReviewEditorModal({ visible, initial, submitting, onClose, onSubmit, }: { visible: boolean; /** 编辑时传入被编辑的评价;新建传 null */ initial: EmployeeReview | null; submitting: boolean; onClose: () => void; onSubmit: (input: JobReviewCreateInput) => void; }) { const [draft, setDraft] = useState(EMPTY_DRAFT); // 每次打开时按 initial 重置,避免上一次的残留内容带进新一次编辑 useEffect(() => { if (visible) setDraft(draftFromReview(initial)); }, [visible, initial]); const toggleTag = (tag: string) => { setDraft((prev) => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...prev.tags, tag], })); }; const setDimension = (key: string, score: number) => { setDraft((prev) => ({ ...prev, dimensionScores: { ...prev.dimensionScores, [key]: score }, })); }; const handleSubmit = () => { if (draft.rating <= 0) { Alert.alert('提示', '请先给一个综合评分'); return; } if (!draft.content.trim()) { Alert.alert('提示', '请先输入你的评价内容'); return; } onSubmit({ rating: draft.rating, content: draft.content.trim(), tags: draft.tags, // 未打分的维度补 0,键齐全便于后端按八维落库 dimensionScores: REVIEW_DIMENSIONS.reduce>((acc, dim) => { acc[dim.key] = draft.dimensionScores[dim.key] ?? 0; return acc; }, {}), }); }; return ( {initial ? '编辑评价' : '写评价'} 综合评分 setDraft((prev) => ({ ...prev, rating: next }))} /> {draft.rating > 0 ? `${draft.rating} 分` : '点击星星打分'} 分项评分(选填) {REVIEW_DIMENSIONS.map((dim) => ( {dim.label} setDimension(dim.key, next)} /> ))} 标签(选填,可多选) {REVIEW_TAG_SUGGESTIONS.map((tag) => { const active = draft.tags.includes(tag); return ( toggleTag(tag)} > {tag} ); })} 评价内容 setDraft((prev) => ({ ...prev, content: text }))} multiline maxLength={500} /> {draft.content.length}/500 {submitting ? ( ) : ( {initial ? '保存修改' : '发布评价'} )} ); } /* ===================== 页面 ===================== */ export default function JobDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const { detail, overallScore, salaryData, reviews, reviewSummary, loading, error, refresh, publishReview, editReview, removeReview, } = useJobDetail(id); const [editorOpen, setEditorOpen] = useState(false); /** 正在编辑的评价;null = 新建 */ const [editing, setEditing] = useState(null); const [submitting, setSubmitting] = useState(false); /** * 本人已发布的评价。后端约束「同一学生对同一岗位仅一条」,故取第一条 canEdit 即可。 * 有它时底部按钮变「编辑我的评价」,避免用户以为能发第二条。 */ const myReview = reviews.find((r) => r.canEdit) ?? null; // 收藏态:用已收藏 id 集合判断是否已收藏(仅 STUDENT 可收藏) const { favoriteIds, toggle: toggleFavorite } = useFavorites(); const favorited = id ? favoriteIds.has(id) : false; /** * 分享岗位:把当前岗位拼成文本经系统分享面板发出。 * `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。 */ const handleShare = async () => { if (!detail) return; const outcome = await shareText( buildJobShareText(detail, overallScore, salaryData, reviews.length), `${detail.positionName}|${detail.companyName}`, ); if (!outcome.ok) Alert.alert('无法分享', outcome.message); }; const openCreate = () => { setEditing(null); setEditorOpen(true); }; const openEdit = (review: EmployeeReview) => { setEditing(review); setEditorOpen(true); }; // 新建 / 编辑共用一个提交口:编辑时忽略岗位覆盖,新建时由 hook 补岗位 id const handleSubmitReview = async (input: JobReviewCreateInput) => { setSubmitting(true); try { if (editing) { await editReview(editing.id, input); } else { await publishReview(input); } setEditorOpen(false); setEditing(null); } catch (e) { Alert.alert(editing ? '保存失败' : '发布失败', (e as Error).message || '请稍后重试'); } finally { setSubmitting(false); } }; // 删除前二次确认:评价删掉不可恢复,不做静默删除 const handleDeleteReview = (review: EmployeeReview) => { Alert.alert('删除评价', '删除后无法恢复,确定要删除这条评价吗?', [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: async () => { try { await removeReview(review.id); } catch (e) { Alert.alert('删除失败', (e as Error).message || '请稍后重试'); } }, }, ]); }; // 错误态 if (error) { return (
router.back()} favorited={false} onToggleFavorite={() => {}} /> 加载失败,请重试 重试 ); } // 加载态 if (loading || !detail || !overallScore || !salaryData) { return (
router.back()} favorited={false} onToggleFavorite={() => {}} /> ); } return (
router.back()} onShare={handleShare} favorited={favorited} onToggleFavorite={id ? () => toggleFavorite(id) : undefined} /> {/* 1. 岗位基本信息 */} {detail.positionName} {detail.status} 浏览{formatThousands(detail.views)} {detail.salaryMin}-{detail.salaryMax}K · {detail.salaryMonths}薪 {detail.companyName} · {detail.industry} · {detail.companySize} {detail.companyLocation} · {detail.experience} · {detail.education} {detail.benefits.map((item) => ( {item} ))} {/* 2. 岗位综合评分 */} {/* 「评分说明」入口已摘除:项目没有评分规则说明页 */} {overallScore.totalScore} 分 {overallScore.level} {overallScore.rank} router.push('/jobs/compare')} hitSlop={6}> 加入对比 {/* 3. 真实薪资区间 */} {/* 「全部」入口已摘除:下面这张卡已经把 4 档经验区间列全了,点进去没有更多内容 */} {salaryData.ranges.map((range, index) => ( {range.experience} {range.min}-{range.max}K ))} {salaryData.dataSource} {/* 4. 员工评价(汇总 → 列表) */} {reviewSummary ? : null} {reviews.length === 0 && ( /* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */ 暂无员工评价 成为第一个分享这家公司真实情况的人 )} {reviews.map((review) => ( openEdit(review)} onDelete={() => handleDeleteReview(review)} /> ))} {/* 5. 评价入口(固定底部):已有本人评价时改为编辑,避免用户以为能重复发布 */} openEdit(myReview) : openCreate} > {myReview ? '编辑我的评价' : '写评价'} { setEditorOpen(false); setEditing(null); }} onSubmit={handleSubmitReview} /> ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: colors.pageBg, }, stateWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', }, stateText: { marginTop: 12, fontSize: 14, color: colors.textSecondary, }, retryBtn: { marginTop: 16, paddingHorizontal: 24, paddingVertical: spacing.card, borderRadius: 18, backgroundColor: colors.primaryBtn, }, retryText: { fontSize: 13, fontWeight: '600', color: colors.white, }, /* ---- 顶部导航 ---- */ header: { height: 48, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary, }, /** * 头部按钮:返回靠左、分享靠右。 * 原先两者共用一条**只写了 `top`、没写 `left`/`right`** 的 absolute 样式, * 在 `justifyContent: 'center'` 的父容器里落到同一处彼此重叠 —— 返回键与分享键 * 都不在用户预期位置(分享键实测点不到)。absolute 必须成对写清水平锚点。 */ headerBtn: { position: 'absolute', top: 0, left: 0, width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, headerBtnRight: { position: 'absolute', top: 0, right: 0, width: 48, height: 48, alignItems: 'center', justifyContent: 'center', }, scroll: { flex: 1, }, content: { paddingHorizontal: spacing.card, paddingBottom: 24, }, card: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, }, /* ---- 空态 ---- */ emptyCard: { backgroundColor: colors.white, borderRadius: spacing.card, paddingVertical: spacing.card, paddingHorizontal: spacing.card, marginBottom: 8, alignItems: 'center', gap: 8, }, emptyText: { fontSize: 14, fontWeight: '600', color: colors.textSecondary, }, emptyHint: { fontSize: 12, color: colors.textHint, textAlign: 'center', }, /* ---- 基本信息 ---- */ basicTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', }, positionName: { flex: 1, fontSize: 22, fontWeight: '700', color: colors.textPrimary, }, statusCol: { alignItems: 'flex-end', marginLeft: 12, }, statusTag: { backgroundColor: colors.successLight, paddingHorizontal: spacing.card, paddingVertical: 3, borderRadius: 10, }, statusText: { fontSize: 12, fontWeight: '600', color: colors.successText, }, viewsText: { marginTop: 6, fontSize: 12, color: colors.textHint, }, salary: { marginTop: 14, fontSize: 20, fontWeight: '700', color: colors.primary, }, metaText: { marginTop: 8, fontSize: 13, color: colors.textSecondary, }, benefits: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 12, }, benefitTag: { backgroundColor: colors.primaryLight, borderWidth: 1, borderColor: colors.primaryBorder, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, marginRight: 8, marginBottom: 8, }, benefitText: { fontSize: 12, color: colors.primary, }, /* ---- 分区标题 ---- */ sectionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, }, sectionBar: { width: 4, height: 16, borderRadius: 2, backgroundColor: colors.primary, marginRight: 8, }, sectionTitle: { flex: 1, fontSize: 17, fontWeight: '700', color: colors.textPrimary, }, sectionLinkBtn: { paddingLeft: 8, }, sectionLink: { fontSize: 13, color: colors.primary, }, /* ---- 综合评分 ---- */ scoreRow: { flexDirection: 'row', alignItems: 'center', }, scoreInfo: { flex: 1, alignItems: 'flex-start', marginLeft: 8, }, scoreNumberRow: { flexDirection: 'row', alignItems: 'baseline', }, scoreNumber: { fontSize: 40, fontWeight: '700', color: colors.primary, }, scoreUnit: { fontSize: 14, fontWeight: '600', color: colors.primary, }, levelTag: { marginTop: 4, backgroundColor: colors.successLight, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, }, levelTagText: { fontSize: 13, fontWeight: '600', color: colors.successText, }, scoreRank: { marginTop: 8, fontSize: 12, color: colors.textSecondary, }, compareBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 16, }, compareText: { marginLeft: 4, fontSize: 14, fontWeight: '600', color: colors.primary, }, /* ---- 薪资区间 ---- */ salaryRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, salaryRowLast: { borderBottomWidth: 0, }, salaryExp: { fontSize: 14, color: colors.textSecondary, }, salaryRange: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, }, salarySource: { marginTop: 12, fontSize: 12, color: colors.textHint, }, /* ---- 员工评价 ---- */ reviewHeader: { flexDirection: 'row', alignItems: 'center', }, reviewName: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, }, reviewRole: { marginLeft: 8, fontSize: 12, color: colors.textSecondary, }, reviewTime: { flex: 1, textAlign: 'right', fontSize: 12, color: colors.textHint, }, reviewContent: { marginTop: 10, fontSize: 13, lineHeight: 20, color: colors.textPrimary, }, reviewTags: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 10, }, reviewTag: { backgroundColor: colors.pageBg, borderWidth: 1, borderColor: colors.border, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, marginRight: 8, marginBottom: 8, }, reviewTagText: { fontSize: 12, color: colors.textSecondary, }, /* ---- 星级 ---- */ starsRow: { flexDirection: 'row', alignItems: 'center', }, starCell: { paddingHorizontal: 1, }, /* ---- 评价汇总 ---- */ summaryTop: { flexDirection: 'row', alignItems: 'center', }, summaryScoreCol: { width: 104, alignItems: 'center', gap: 4, }, summaryScore: { fontSize: 30, fontWeight: '700', color: colors.textPrimary, }, summaryTotal: { fontSize: 11, color: colors.textHint, }, summaryBars: { flex: 1, marginLeft: 12, gap: 4, }, summaryBarRow: { flexDirection: 'row', alignItems: 'center', }, summaryBarLabel: { width: 30, fontSize: 11, color: colors.textHint, }, summaryBarTrack: { flex: 1, height: 6, borderRadius: 3, backgroundColor: colors.pageBg, overflow: 'hidden', }, summaryBarFill: { height: 6, borderRadius: 3, backgroundColor: STAR_COLOR, }, summaryBarCount: { width: 26, textAlign: 'right', fontSize: 11, color: colors.textHint, }, /* ---- 评价卡 ---- */ reviewRatingRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 4, }, reviewRatingText: { fontSize: 12, fontWeight: '600', color: STAR_COLOR, }, dimGrid: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 10, gap: spacing.card, }, dimCell: { minWidth: 72, paddingHorizontal: 8, paddingVertical: 6, borderRadius: spacing.card, backgroundColor: colors.pageBg, }, dimLabel: { fontSize: 11, color: colors.textHint, }, dimValue: { marginTop: 2, fontSize: 14, fontWeight: '700', color: colors.textPrimary, }, reviewActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: colors.borderLight, gap: 16, }, reviewActionBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, }, reviewActionText: { fontSize: 12, fontWeight: '600', color: colors.primaryText, }, reviewActionDanger: { color: colors.dangerText, }, /* ---- 底部评价入口 ---- */ commentBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.framework, paddingVertical: 10, backgroundColor: colors.white, borderTopWidth: 1, borderTopColor: colors.borderLight, }, composeBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, height: 40, borderRadius: 20, backgroundColor: colors.primaryBtn, }, publishText: { fontSize: 14, fontWeight: '600', color: colors.white, }, /* ---- 评价编辑器弹窗 ---- */ modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end', }, modalSheet: { maxHeight: '86%', backgroundColor: colors.white, borderTopLeftRadius: spacing.framework, borderTopRightRadius: spacing.framework, }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.framework, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, }, modalBody: { paddingHorizontal: spacing.framework, paddingTop: 12, }, fieldLabel: { marginTop: 14, marginBottom: 8, fontSize: 13, fontWeight: '600', color: colors.textPrimary, }, ratingRow: { flexDirection: 'row', alignItems: 'center', gap: 12, }, ratingHint: { fontSize: 13, color: colors.textSecondary, }, dimEditor: { gap: 6, }, dimEditorRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, dimEditorLabel: { fontSize: 13, color: colors.textSecondary, }, tagPicker: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.card, }, tagChip: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: spacing.card, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.white, }, tagChipActive: { borderColor: colors.primary, backgroundColor: colors.primaryLight, }, tagChipText: { fontSize: 12, color: colors.textSecondary, }, tagChipTextActive: { color: colors.primaryText, fontWeight: '600', }, modalInput: { minHeight: 96, borderRadius: spacing.card, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.pageBg, paddingHorizontal: spacing.card, paddingVertical: spacing.card, fontSize: 13, lineHeight: 20, color: colors.textPrimary, textAlignVertical: 'top', }, counterText: { marginTop: 6, textAlign: 'right', fontSize: 11, color: colors.textHint, }, modalFooter: { paddingHorizontal: spacing.framework, paddingTop: 10, paddingBottom: 24, }, submitBtn: { height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primaryBtn, }, submitBtnDisabled: { opacity: 0.6, }, submitText: { fontSize: 15, fontWeight: '700', color: colors.white, }, });